Skip to content

Conversation

dingxiangfei2009
Copy link
Contributor

@dingxiangfei2009 dingxiangfei2009 commented Jan 15, 2025

Replace #127522
Related to #62958

The problem statement

#62958 demonstrates two problems. One is that upvars are always unconditionally promoted to prefix data fields of the state machine; the other is that the opportunity to achieve a more compact data layout is lost because captured upvars are not subjected to liveness analysis, in the sense that the memory space at one point occupied by upvars is never reclaimed and made available for other saved data across certain yield points, even when they are dead at those suspension locations.

The second problem is better demonstrated with this code snippet.

async fn work(another_fut: impl Future) {
    let _ = another_fut.await;
    // now `another_fut` is consumed
    let next_fut = async { .. };
    next_fut.await;
}

// `work`'s layout needs to reserve space for both `another_fut` and `next_fut`, while there is a clear missed opportunity
// to overlap the memory for `another_fut` and `next_fut` for better memory economy.

The difficulty lies with the fact that captured upvars do not receive their own locals inside a coroutine body. If we can assign locals to them somehow, we can run the layout scheme as usual and the optimisation on the data layout comes into effect out of the box in most cases.

Proposed changes

This is an initial work to improve memory economy of coroutine and async futures, by reducing the unnecessary of promotion of captured upvars into state prefix.

The patch contains the following changes.

  1. Introduction of a RelocateUpvar MIR pass that inserts a MIR gadget, through which captured values by coroutine or async bodies or closures are moved into the inner MIR locals. This opens opportunities to subject the captured upvars to the same liveness analysis and determine which are the necessary ones to be stored in the coroutine state during suspension.
  2. With this gadget, it means that we do not have to keep all upvars in the so-called prefix data regions of coroutine states. Instead, they are moved into the Unresumed state, or by convention the first variants of the state ADTs.
  3. In addition, in case that some upvars are eventually used across more than one suspension point, which leads to their promotion into the prefix after all, we further arrange the coroutine state data layout, so that their offsets in the Unresumed state coincide with their memory slots after promotion. This means that during codegen, the additional moves introduced by the RelocateUpvar gadget are actually elided. The relevant change is implemented in rustc_abi.
  4. We then have to pay the lip service to translate direct field access to the upvars into access behind the Unresumed variant.
  5. We assert invariance relation between types of the captured upvars and types of the respective relocated locals.
  6. We have to update diagnostics so that they are more informed about captured values and they make more sense in view of this change.
  7. As requested by the review comments, the relocation only applies behind an unstable compiler flag -Z pack-coroutine-layout=captures-only. The default is pack-coroutine-layout=no, so that we keep the layout aligned with the stable.

Other than upvars, the coroutine state data layout scheme remains largely the same.

Design decisions

Why does this patch not perform relocation as part of the StateTransform pass?

This idea is explored in #120168 already back in 2023. The conclusion then was that it does not interact well with MIR dataflow analysis. It requires StateTransform pass to assign a virtual "MIR local" to each upvars at the beginning. Apparently this created difficulty in reviewing the piece as soon as we overload this huge StateTransform pass with this additional renumbering work. The idea has always been that it is better to perform the renumbering in its own pass, to keep StateTransform simple.

This patch has gone further to carry out the re-write as early as possible, so that the passes in between can perform rewrites as per current MIR local semantics and optimisation rules.

Further optimisation to be implemented behind a feature gate

Point 4 mentions that any local to be saved across suspensions will be promoted whenever they are alive across two or more yield locations. We would like to run an experiment behind a feature gate on improvements of the layout scheme. For ease of reviewing, it is better to drop this part of work from this PR. Nevertheless, the idea runs along the implementation in #127522 and we intend to propose a second PR just for that.

Old PR description Good day, this PR is related to #127522 and it is made easier to the public to test out a new coroutine/`async` state machine directly.

Prepare the compiler for tests

For starter, you may build the compiler as prescribed in the rustc-dev-guide instruction. If a test in the docker container is desirable, you may build this compiler with src/ci/docker/run.sh dist-x86_64-linux --dev for x86_64 and package the compiler with ../x dist to produce the artifacts in obj/dist-x86_64-linux/build/dist. This Dockerfile gets you a working Rust builder image which allows you to build your Rust applications in bookworm.

The state of performance

So far with this patch, I have been studying the performance impact on the cases of tokio's single- and multi-threaded runtime, as well as a simple axum HTTP service. As far as I can see, I can find a change in performance characteristics that are statistically significant, one-sided p = 0.05.

This time, I would like to call for pooling in your valuable assessments and thoughts on this patch. I kindly request experiments from you and hopefully you can provide regression cases with perf record -e cycles:u,instructions:u,cache-misses:u reports.

Thank you all so much! 🙇

@rustbot
Copy link
Collaborator

rustbot commented Jan 15, 2025

r? @BoxyUwU

rustbot has assigned @BoxyUwU.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. labels Jan 15, 2025
@rustbot
Copy link
Collaborator

rustbot commented Jan 15, 2025

Some changes occurred in compiler/rustc_codegen_cranelift

cc @bjorn3

Some changes occurred to the CTFE / Miri interpreter

cc @rust-lang/miri

Some changes occurred to MIR optimizations

cc @rust-lang/wg-mir-opt

Some changes occurred to the CTFE machinery

cc @rust-lang/wg-const-eval

@rust-log-analyzer

This comment has been minimized.

@bors
Copy link
Collaborator

bors commented Jan 19, 2025

☔ The latest upstream changes (presumably #135715) made this pull request unmergeable. Please resolve the merge conflicts.

@BoxyUwU BoxyUwU removed their assignment Jan 28, 2025
@BoxyUwU
Copy link
Member

BoxyUwU commented Jan 28, 2025

I don't think this needs a reviewer?

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from 3e6a399 to 9603ad6 Compare January 28, 2025 23:17
@traviscross
Copy link
Contributor

cc @Darksonn @tmandry @eholk @rust-lang/wg-async

Ding here is reworking the layout of coroutines to try to reduce their memory footprint (and that of Futures). He's curious to find whether this introduces any performance or other regressions. In this own testing, he's not been able to find any, but he's curious in more data and experience here to help inform whether this is a worthwhile change.

What do people think?

@rust-log-analyzer

This comment has been minimized.

@tmandry
Copy link
Member

tmandry commented Jan 30, 2025

For anyone searching for a description of what this PR changes, it's summarized at the top of compiler/rustc_mir_transform/src/coroutine/relocate_upvars.rs.

Comment on lines 20 to 21
//! The reason is that it is possible that coroutine layout may change and the source memory location of
//! an upvar may not necessarily be mapped exactly to the same place as in the `Unresumed` state.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we decide the offsets of upvars in Unresumed in the same place as we decide the offset of saved locals? Couldn't we then "backpropagate" the field offsets for each upvar's local as the offset for the corresponding upvar?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for reviewing! I had a backlog of things due to sickness.

True indeed. This statement is completely voided by the work in the second commit. I will reword this section in the following way.


By enabling the feature gate coroutine_new_layout the field offsets of the upvars in Unresumed state are further exactly placed in the same place as their corresponding saved locals, which is guaranteed by the alternative coroutine layout calculator that enters in effect. <... quote the relevant comment/file/etc. ...>

@tmandry
Copy link
Member

tmandry commented Jan 30, 2025

I don't personally have any means of performance testing this at the moment. It would be much easier if it landed behind a feature gate.

@bors
Copy link
Collaborator

bors commented Jan 31, 2025

☔ The latest upstream changes (presumably #135318) made this pull request unmergeable. Please resolve the merge conflicts.

@nikomatsakis
Copy link
Contributor

nikomatsakis commented Feb 1, 2025 via email

@dingxiangfei2009
Copy link
Contributor Author

@tmandry

I don't personally have any means of performance testing this at the moment. It would be much easier if it landed behind a feature gate.

I think it is fair to land with a feature gate so that we can get to play with it. The PR has temporarily disabled the check on the feature gate. However, given that coroutine layout data is keyed individually by their DefId, I think it is still safe to allow code to link to each other even when the feature gate status varies among the crates.

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from 9603ad6 to 3a1e04a Compare February 9, 2025 20:19
@rust-log-analyzer

This comment has been minimized.

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from 3a1e04a to 61d4bbd Compare February 9, 2025 23:12
@rust-log-analyzer

This comment has been minimized.

@eholk
Copy link
Contributor

eholk commented Feb 12, 2025

I don't personally have any means of performance testing this at the moment. It would be much easier if it landed behind a feature gate.

Would this be better as a #[feature(...)] gate, or as -Z new_coroutine_layout? I think the compiler flag feels like a better fit for something like this.

@oli-obk
Copy link
Contributor

oli-obk commented Feb 13, 2025

Are there any issues if only one crate activates it but others do not? if there are no issues, a feature gate seems ok (and easier to use ^^)

@bors
Copy link
Collaborator

bors commented Feb 14, 2025

☔ The latest upstream changes (presumably #137030) made this pull request unmergeable. Please resolve the merge conflicts.

@Dirbaio
Copy link
Contributor

Dirbaio commented Feb 15, 2025

A feature doesn't allow turning it on for the whole build, you'd have to fork every single crate that uses async. A -Z flag would be better IMO.

@tmandry
Copy link
Member

tmandry commented Feb 18, 2025

Agreed on a -Z flag being better for testing for the reason @Dirbaio gave.

If my understanding is correct, we shouldn't expect any regression from this approach (only upside), but since we currently rely on later passes eliding copies there might be some regression. We could be more aggressive in eliding the copies ourselves, but maybe this is hard.

@dingxiangfei2009
Copy link
Contributor Author

Thanks for looking into this!

I will have time this week to clean this up a bit and I will ask rustbot to set it to ready-for-review.

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from 61d4bbd to 0ff7e65 Compare March 9, 2025 22:29
@rustbot
Copy link
Collaborator

rustbot commented Mar 9, 2025

Some changes occurred in compiler/rustc_codegen_ssa

cc @WaffleLapkin

@@ -375,6 +375,9 @@ pub struct Body<'tcx> {
#[type_foldable(identity)]
#[type_visitable(ignore)]
pub function_coverage_info: Option<Box<coverage::FunctionCoverageInfo>>,

/// Coroutine local-upvar map
pub local_upvar_map: IndexVec<FieldIdx, Option<Local>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you say a bit more about what this map does, and in particular how it affects the operational semantics of MIR? You didn't adjust the interpreter to use this field, so -- is it correct for a backend to entirely ignore this field?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added the comment. Basically this map is for diagnostic and asserting region invariance between the relocated locals and the upvars.

//! the base. For instance, `(_1.4 as Some).0` is rewritten into `(_34 as Some).0` when `_34` is the fresh local
//! corresponding to the captured upvar stored in `_1.4`.
//!
//! 3. It assembles an prologue to replace the current entry block.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//! 3. It assembles an prologue to replace the current entry block.
//! 3. It assembles a prologue to replace the current entry block.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in rebase

//!
//! This prologue block transfers every captured upvar into its corresponding fresh local, *via scratch locals*.
//! The upvars are first completely moved into the scratch locals in batch, and then moved into the destination
//! locals in batch.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand what these "scratch" locals are doing or why they are needed. Could you add an example?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example added.

The problem lies with a possible permutation of captures when coroutine transit from the unresumed state to other state.

Now that I have given it a good thought and I would like to discuss with the team about a more general fix. I will raise it in the Zulip thread mentioned in the review comment.

//!
//! Each place that starts with access into the coroutine structure `_1` is replaced with the fresh local as
//! the base. For instance, `(_1.4 as Some).0` is rewritten into `(_34 as Some).0` when `_34` is the fresh local
//! corresponding to the captured upvar stored in `_1.4`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we ensure that mutations of the local representing the upvar are properly applied, like for an FnMut? Right now the test sounds like we'd mutate a copy instead which would give the wrong behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope that our discussion in #140132 has arrived at the consensus. Otherwise, I am opening a Zulip thread for discussion.

Copy link
Member

@RalfJung RalfJung Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the discussion there didn't really reach a consensus, did it? We figured out what the actual proposal is, but there hasn't been consensus for actually doing this. E.g. @compiler-errors sounded far from convinced.

I'm afraid I don't have the bandwidth to follow this proposal so I will have to bow out here, but I think it may need involvement of @rust-lang/types or even t-lang.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

//! 2. It replaces the places pointing into those upvars with places pointing into those locals instead
//!
//! Each place that starts with access into the coroutine structure `_1` is replaced with the fresh local as
//! the base. For instance, `(_1.4 as Some).0` is rewritten into `(_34 as Some).0` when `_34` is the fresh local
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the role of the as Some and .0 in this example? I am a bit confused.

I think it'd be good to extend the example and show the entire transform, including the "prologue", on a super simple MIR body.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alongside a short description, I also attached a new mir-opt test to describe how the prologue would look like.

@bors
Copy link
Collaborator

bors commented Jul 17, 2025

☔ The latest upstream changes (presumably #141762) made this pull request unmergeable. Please resolve the merge conflicts.

@Dylan-DPC
Copy link
Member

@dingxiangfei2009 any updates on answering the review and resolving the conflicts? thanks

@dingxiangfei2009
Copy link
Contributor Author

dingxiangfei2009 commented Aug 15, 2025

@Dylan-DPC Yes, I just finished rebasing today. There was a new test that I have to fix. I will push it at a later hour.

@dingxiangfei2009
Copy link
Contributor Author

I will also push some updated documentation. I also feel that some items should be discussed in Zulip. Stay tuned.

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from faabda2 to be16344 Compare August 18, 2025 08:04
@rustbot

This comment has been minimized.

@rust-log-analyzer

This comment has been minimized.

Copy link
Contributor

@cjgillot cjgillot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm starting to review this, but I have trouble to understand one of the key design points.

Why is RelocateUpvars separate from StateTransform? Could we start by a change that is localized in StateTransform and then in another PR change analysis MIR?

About the changes to StateTransform itself, is there a way to make the transform use the existing infra? For instance having adding a yield terminator at the end of bb0 which will correspond to the unresumed state?

@bors
Copy link
Collaborator

bors commented Aug 21, 2025

☔ The latest upstream changes (presumably #145244) made this pull request unmergeable. Please resolve the merge conflicts.

@davidtwco davidtwco assigned cjgillot and unassigned davidtwco Aug 21, 2025
@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from be16344 to bb5cf56 Compare August 22, 2025 09:05
@rustbot

This comment has been minimized.

@rust-log-analyzer

This comment has been minimized.

@bors
Copy link
Collaborator

bors commented Aug 23, 2025

☔ The latest upstream changes (presumably #145773) made this pull request unmergeable. Please resolve the merge conflicts.

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from bb5cf56 to 16fd7c2 Compare August 25, 2025 12:34
@rustbot

This comment has been minimized.

@rust-log-analyzer

This comment has been minimized.

@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from 16fd7c2 to ead7829 Compare August 25, 2025 13:54
@rust-log-analyzer

This comment has been minimized.

dingxiangfei2009 and others added 4 commits August 25, 2025 15:30
... and treat coroutine upvar captures as saved locals as well.

This allows the liveness analysis to determine which captures are truly
saved across a yield point and which are initially used but discarded at
first yield points.

In the event that upvar captures are promoted, most certainly because
a coroutine suspends at least once, the slots in the promotion prefix
shall be reused. This means that the copies emitted in the upvar
relocation MIR pass will eventually elided and eliminated in the codegen
phase, hence no additional runtime cost is realised.

Additional MIR dumps are inserted so that it is easier to inspect the
bodies of async closures, including those that captures the state
by-value.

Debug information is updated to point at the correct location for upvars
in borrow checking errors and final debuginfo.

A language change that this patch enables is now actually reverted,
so that lifetimes on relocated upvars are invariant with the upvars outside
of the coroutine body.
We are deferring the language change to a later discussion.

Co-authored-by: Dario Nieuwenhuis <[email protected]>
Signed-off-by: Xiangfei Ding <[email protected]>
@dingxiangfei2009 dingxiangfei2009 force-pushed the move-upvars-to-locals-for-tests branch from ead7829 to 0ceb235 Compare August 25, 2025 15:31
@rustbot
Copy link
Collaborator

rustbot commented Aug 25, 2025

This PR was rebased onto a different master commit. Here's a range-diff highlighting what actually changed.

Rebasing is a normal part of keeping PRs up to date, so no action is needed—this note is just to help reviewers.

@rust-log-analyzer
Copy link
Collaborator

The job tidy failed! Check out the build log: (web) (plain enhanced) (plain)

Click to see the possible cause of the failure (guessed by this bot)
spellcheck files
building external tool typos from package [email protected]
finished building tool typos
npm WARN deprecated [email protected]: This version is no longer supported. Please see https://eslint.org/version-support for other options.
npm ERR! code E403
npm ERR! 403 403 Forbidden - GET https://registry.npmjs.org/zod/-/zod-3.23.8.tgz
npm ERR! 403 In most cases, you or one of your dependencies are requesting
npm ERR! 403 a package version that is forbidden by your security policy, or
npm ERR! 403 on a server you do not have access to.

npm ERR! A complete log of this run can be found in: /home/user/.npm/_logs/2025-08-25T15_38_28_737Z-debug-0.log
tidy error: IO error: npm install returned exit code exit status: 1
npm install did not exit successfully
some tidy checks failed
Command `/checkout/obj/build/x86_64-unknown-linux-gnu/stage1-tools-bin/rust-tidy /checkout /checkout/obj/build/x86_64-unknown-linux-gnu/stage0/bin/cargo /checkout/obj/build 4 /node/bin/npm --extra-checks=py,cpp,js,spellcheck` failed with exit code 1
Created at: src/bootstrap/src/core/build_steps/tool.rs:1583:23
Executed at: src/bootstrap/src/core/build_steps/test.rs:1225:29

Command has failed. Rerun with -v to see more details.
Bootstrap failed while executing `test --stage 0 src/tools/tidy tidyselftest --extra-checks=py,cpp,js,spellcheck`
Build completed unsuccessfully in 0:02:58
  local time: Mon Aug 25 15:38:50 UTC 2025
  network time: Mon, 25 Aug 2025 15:38:50 GMT
##[error]Process completed with exit code 1.

@dingxiangfei2009
Copy link
Contributor Author

dingxiangfei2009 commented Aug 25, 2025

Thank you so much for reviewing, @cjgillot !

localized in StateTransform

There was a back story here. While I have updated the PR description to explain, I am also reproducing the text here as well.

This idea is explored in #120168 already back in 2023. The conclusion then was that it does not interact well with MIR dataflow analysis. It requires StateTransform pass to assign a virtual "MIR local" to each upvars at the beginning. Apparently this created difficulty in reviewing the piece as soon as we overload this huge StateTransform pass with this additional renumbering work. The idea has always been that it is better to perform the renumbering in its own pass, to keep StateTransform simple.

a yield terminator at the end of bb0

I am afraid this would introduce a breaking change, through which coroutines and futures need to be polled one extra time in order to start driving. This is because in the event construction of coroutines, the control flow does not enter the coroutine body by design.

make the transform use the existing infra

However I have been thinking about this. StateTransform operates exclusively on MIR locals in the body. To maximally utilise the StateTransform infra, it is best if we make upvars representable as MIR locals. This patch demonstrates one way to do so, which is through MIR builder emitting statements to move the upvars from the struct fields into true MIR locals.

There is the other way that motivates me to initiate the discussion on the thread #t-lang > Should we lift captures of coroutines into MIR arguments?. This idea is very tempting because of the following reasons.

  • We observe that coroutine captures still work in the same way as if we move the upvars into user-defined locals. So there is no breaking changes in the language.
  • Imagine that we express upvars as _1, _2, etc, in the body arguments, even if they are only formal until the analysis phase, we will need no RelocateUpvars pass and the shenanigans.
  • We will also furthermore improve the coroutine witness type because it plays both the role as symbolic coroutine state with pure struct fields in MIRs before StateTransform, and mixed struct/enum data with a prefix like a struct, a discriminant and a enum-like suffix after StateTransform. Having either of the two layouts is not a problem, but having both at the same time is for us a probable issue because how one interprets this data and which (symbolic) layout applies depends on which phase this coroutine MIR is passing through. Promoting the upvars into proper body arguments can solve this issue because we do not need to work with the pre-StateTransform data anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-LLVM Area: Code generation parts specific to LLVM. Both correctness bugs and optimization-related issues. S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. T-clippy Relevant to the Clippy team. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue.
Projects
None yet
Development

Successfully merging this pull request may close these issues.